다국어 앱에서 locale을 여러 런타임에 동기화하기
다국어 앱에서 locale을 여러 런타임에 동기화하기
시스템 locale, 사용자가 앱에서 선택한 언어, 콘텐츠 언어, time zone은 서로 다른 값이다. 사용자 설정을 system 또는 명시적인 BCP 47 language tag로 저장하고, 지원 목록에 맞춰 계산한 effectiveLocale을 Flutter·WebView·Backend·Widget에 version 있는 snapshot으로 전달한다. Time zone은 locale에서 추측하지 않고 Asia/Seoul 같은 IANA identifier로 별도 관리한다. 날짜와 숫자는 각 런타임에서 같은 의미 데이터와 locale로 format하고, 알림은 발송 시점의 최신 preference로 생성한다.
목차
- #Locale 하나에 너무 많은 의미를 넣으면 생기는 문제
- #언어와 지역과 Time Zone을 분리하기
- #Source of Truth와 우선순위 정하기
- #BCP 47 Language Tag를 계약으로 사용하기
- #지원 Locale과 Fallback 규칙 정의하기
- #Version 있는 Locale Preference 만들기
- #Flutter에서 Effective Locale 적용하기
- #WebView에는 Bridge Snapshot과 Event로 전달하기
- #Backend에는 사용자의 명시적 설정만 동기화하기
- #Widget은 독립 실행을 전제로 Snapshot을 읽기
- #날짜 저장과 지역화된 표시를 분리하기
- #서버 알림은 발송 시점의 설정으로 만들기
- #번역 문자열을 이어 붙이지 않기
- #동시 변경과 Offline 상태 합치기
- #테스트할 Locale과 시간 경계
- #운영에서 번역 누락과 Drift 관측하기
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
Locale 하나에 너무 많은 의미를 넣으면 생기는 문제
Flutter 앱 안에 WebView와 iOS Widget이 있고 Backend가 push notification을 만든다고 하자. 각 환경이 자기 방식으로 locale을 선택하면 한 사용자가 동시에 여러 언어를 보게 된다.
Flutter UI 기기 locale ko-KR → 한국어
WebView Accept-Language en-US → 영어
Widget extension locale ko-KR → 한국어
Push server 계정 가입 국가 US → 영어
앱 설정에서 영어를 선택했는데 Widget은 계속 한국어일 수도 있다. 언어를 ko로 바꿨더니 날짜의 기준 time zone까지 서울로 바뀌는 잘못된 추론도 생긴다.
문제는 모든 런타임이 locale을 “발견”하려 한다는 데 있다. 사용자가 앱 안에서 언어를 직접 선택했다면 그 값이 source of truth여야 한다. 나머지 런타임은 독립적으로 다시 추정하지 않고 선택 결과를 소비한다.
flowchart LR
A[System locale list] --> C[Locale resolver]
B[User language preference] --> C
C --> D[Effective locale]
D --> E[Flutter]
D --> F[WebView]
D --> G[Widget]
B --> H[Backend profile]
I[User time zone] --> E
I --> F
I --> G
I --> H입력은 system preference와 user override로 나누고, 출력은 현재 앱이 실제 사용하는 effective locale로 만든다. Time zone은 별도 축이다.
이 글의 계정과 설정 값은 실제 프로젝트 데이터가 아닌 가상 예시다.
언어와 지역과 Time Zone을 분리하기
다음 값은 비슷해 보이지만 같은 질문에 답하지 않는다.
| 값 | 예 | 답하는 질문 |
|---|---|---|
| Language | ko |
어떤 언어로 표현할까 |
| Script | Hant |
어떤 문자 체계를 사용할까 |
| Region | KR |
어떤 지역 관습을 참고할까 |
| Locale tag | ko-KR |
formatting·번역 선택 맥락 |
| Time zone | Asia/Seoul |
한 instant를 어느 지역 시각으로 볼까 |
| Currency | KRW |
어떤 통화 단위를 쓸까 |
en-US 사용자가 서울에 거주할 수 있고 ko-KR UI를 사용하면서 America/Los_Angeles 일정으로 일할 수도 있다. locale에서 time zone을 추정하지 않는다.
{
"languagePreference": {
"mode": "explicit",
"tag": "en-US"
},
"timeZone": "Asia/Seoul"
}
통화도 account 또는 transaction의 통화 코드를 사용한다. locale은 1,234.50 같은 표시 형식을 정할 수 있지만 값을 USD로 바꿀 권한은 없다.
Source of Truth와 우선순위 정하기
언어 선택 우선순위를 명시한다.
1. 앱에서 사용자가 명시적으로 선택한 locale
2. mode가 system이면 OS preferred locale 중 지원되는 첫 값
3. 앱 기본 locale
sealed class LanguagePreference {
const LanguagePreference();
}
final class FollowSystem extends LanguagePreference {
const FollowSystem();
}
final class ExplicitLocale extends LanguagePreference {
const ExplicitLocale(this.tag);
final String tag;
}
“설정 없음”과 “시스템 설정을 따름”을 같은 null로만 표현하면 migration 때 의미가 모호해질 수 있다. mode를 명시하면 사용자가 선택한 값인지 계산된 값인지 구분된다.
Locale resolveEffectiveLocale({
required LanguagePreference preference,
required List<Locale> systemLocales,
required List<Locale> supportedLocales,
required Locale defaultLocale,
}) {
return switch (preference) {
ExplicitLocale(:final tag) =>
matchSupportedLocale(parseLocale(tag), supportedLocales)
?? defaultLocale,
FollowSystem() =>
firstSupported(systemLocales, supportedLocales)
?? defaultLocale,
};
}
Backend는 device system locale을 계정의 명시적 설정으로 덮어쓰지 않는다. 여러 기기에서 서로 다른 system locale을 쓰는 사용자가 있을 수 있다.
BCP 47 Language Tag를 계약으로 사용하기
런타임 사이 payload에는 ko_KR, ko-KR, Korean, kr가 섞이지 않게 BCP 47 형식의 tag를 사용한다.
ko
ko-KR
en-US
pt-BR
zh-Hans-CN
zh-Hant-TW
KR은 국가 코드이고 한국어 language code는 ko다. zh-TW처럼 script 차이가 중요한 언어는 단순히 language subtag만 비교하면 잘못된 번역 자산을 고를 수 있다.
경계에서 canonicalize한다.
String canonicalLocaleTag(Locale locale) {
return [
locale.languageCode.toLowerCase(),
if (locale.scriptCode != null)
canonicalScript(locale.scriptCode!),
if (locale.countryCode != null)
locale.countryCode!.toUpperCase(),
].join('-');
}
실제 구현에서는 표준 locale library와 플랫폼 canonicalization을 사용하고 임의 parser를 완전한 표준 구현으로 확대하지 않는다. 알 수 없는 extension subtag를 보존할지, 앱이 지원하는 language-script-region만 받을지도 계약으로 정한다.
지원 Locale과 Fallback 규칙 정의하기
번역 asset 지원 목록과 server template 지원 목록이 다르면 drift가 생긴다.
const supportedLocales = [
Locale('ko', 'KR'),
Locale('en', 'US'),
Locale('ja', 'JP'),
Locale.fromSubtags(
languageCode: 'zh',
scriptCode: 'Hant',
countryCode: 'TW',
),
];
fallback은 단순 문자열 앞 두 글자만 자르지 않는다.
zh-Hant-TW
→ zh-Hant
→ 앱이 지원하는 Chinese fallback
→ default locale
정책 예:
| 요청 | 지원 | 결과 |
|---|---|---|
ko-KR |
ko-KR |
ko-KR |
ko-US |
ko-KR |
ko-KR |
en-GB |
en-US |
제품 정책에 따라 en-US |
zh-Hant-HK |
zh-Hant-TW |
script 우선 match |
fr-FR |
없음 | default en-US |
Flutter의 기본 locale resolution을 사용할 수 있지만 앱·Web·Backend가 같은 결과를 내는지 확인한다. 별도 callback을 작성한다면 공통 fixture로 검증한다.
사용자가 지원 locale을 선택했는데 특정 key만 default 언어로 보이는 문제는 asset 누락이다. locale 전체 fallback과 message key fallback을 별도 지표로 본다.
Version 있는 Locale Preference 만들기
여러 런타임에 전달할 상태를 envelope로 만든다.
{
"schemaVersion": 1,
"preferenceVersion": 18,
"language": {
"mode": "explicit",
"tag": "ko-KR"
},
"effectiveLocale": "ko-KR",
"timeZone": "Asia/Seoul",
"updatedAt": "2026-02-03T01:20:00Z"
}
각 필드의 의미:
schemaVersion: payload 형식preferenceVersion: 오래된 쓰기 판별language.mode: system 또는 explicitlanguage.tag: 명시 선택일 때 원본effectiveLocale: 현재 기기에서 적용한 지원 localetimeZone: IANA identifierupdatedAt: 충돌 판단 보조와 진단
version을 기준으로 새 snapshot만 적용한다. timestamp만 비교하면 기기 시계 오차가 충돌을 만든다.
bool shouldApply(
LocaleSnapshot current,
LocaleSnapshot incoming,
) {
if (incoming.schemaVersion != 1) return false;
return incoming.preferenceVersion >
current.preferenceVersion;
}
Flutter에서 Effective Locale 적용하기
Flutter root가 locale state를 구독하고 MaterialApp에 전달한다.
class AppRoot extends StatelessWidget {
const AppRoot({
required this.localeController,
super.key,
});
final LocaleController localeController;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: localeController,
builder: (context, _) {
return MaterialApp.router(
locale: localeController.effectiveLocale,
supportedLocales: supportedLocales,
localizationsDelegates:
AppLocalizations.localizationsDelegates,
routerConfig: appRouter,
);
},
);
}
}
사용자가 system을 선택하면 OS locale 변화도 resolver에 반영한다. explicit locale이면 system 변화로 바꾸지 않는다.
void onPlatformLocalesChanged(List<Locale> locales) {
_systemLocales = locales;
if (_preference is FollowSystem) {
_recomputeAndPublish();
}
}
language 변경으로 widget tree가 rebuild될 수 있으므로 route와 form state를 불필요하게 초기화하지 않는다. locale controller를 router보다 안정적인 상위 수명에 둔다.
WebView에는 Bridge Snapshot과 Event로 전달하기
WebView가 navigator.language을 읽으면 앱의 explicit 설정과 달라질 수 있다. bridge handshake에서 snapshot을 제공하고 변경 event를 보낸다.
{
"protocolVersion": 1,
"kind": "event",
"type": "LOCALE_PREFERENCE_CHANGED",
"eventId": "evt-locale-demo-19",
"sessionId": "page-demo-7",
"payload": {
"preferenceVersion": 18,
"effectiveLocale": "ko-KR",
"timeZone": "Asia/Seoul"
}
}
Web은 version이 더 큰 값만 적용한다.
function applyLocaleSnapshot(
incoming: LocaleSnapshot,
): void {
if (
currentLocale &&
incoming.preferenceVersion <=
currentLocale.preferenceVersion
) {
return;
}
i18n.changeLanguage(incoming.effectiveLocale);
dateTimeFormatter.setContext({
locale: incoming.effectiveLocale,
timeZone: incoming.timeZone,
});
currentLocale = incoming;
renderApp();
}
event를 놓칠 수 있으므로 page reload와 resume 때 LOCALE_PREFERENCE_GET request로 현재 snapshot을 다시 읽는다. 자세한 bridge envelope은 WebView 브릿지를 버전 있는 프로토콜로 만들기에서 다뤘다.
Web의 Intl.DateTimeFormat에는 locale과 timeZone을 함께 명시한다.
const formatter = new Intl.DateTimeFormat(
snapshot.effectiveLocale,
{
dateStyle: "medium",
timeStyle: "short",
timeZone: snapshot.timeZone,
},
);
Backend에는 사용자의 명시적 설정만 동기화하기
서버는 push, email, server-rendered 문구를 만들기 위해 계정 preference가 필요할 수 있다.
PUT /v1/me/localization-preference HTTP/1.1
Content-Type: application/json
If-Match: "locale-pref-17"
{
"languageMode": "explicit",
"languageTag": "ko-KR",
"timeZone": "Asia/Seoul"
}
여러 기기에서 변경할 수 있으므로 version 또는 ETag로 충돌을 드러낸다. last-write-wins를 사용하더라도 기준을 server sequence로 둔다.
system mode의 의미가 계정 전체인지 기기별인지 결정해야 한다.
- 계정 전역 언어라면 system을 선택한 기기의 effective locale을 server에 저장
- 기기별 알림 언어라면 device installation preference를 별도로 저장
- 이메일은 계정 preference, push는 device preference를 사용할 수도 있음
하나의 users.locale 컬럼으로 모든 채널을 설명하려 하면 여러 기기에서 충돌한다.
Widget은 독립 실행을 전제로 Snapshot을 읽기
Widget extension은 Flutter memory를 볼 수 없다. App Group snapshot에 effective locale과 time zone, 표시 의미 데이터를 함께 저장한다.
struct WidgetLocalizationContext: Codable {
let schemaVersion: Int
let preferenceVersion: Int
let localeIdentifier: String
let timeZoneIdentifier: String
}
위젯이 의미 값을 받아 자기 translation asset으로 format할 수 있다.
let locale = Locale(
identifier: context.localeIdentifier
)
let timeZone = TimeZone(
identifier: context.timeZoneIdentifier
) ?? .current
let dateText = snapshot.recordedAt.formatted(
Date.FormatStyle()
.locale(locale)
.timeZone(timeZone)
)
또는 Flutter 앱이 이미 지역화한 짧은 문자열을 snapshot에 포함할 수 있다.
| 방식 | 장점 | 단점 |
|---|---|---|
| 의미 값 + Widget 번역 | time zone 변화 대응 | 번역 asset 두 벌 |
| 완성 문자열 snapshot | 앱과 문구 일치 | locale 변화마다 재생성 |
어느 방식을 택하든 locale 변경 뒤 snapshot을 새 revision으로 저장하고 timeline reload를 요청한다. App Group 구조는 Flutter와 iOS WidgetKit 사이에 데이터 공유하기와 이어진다.
날짜 저장과 지역화된 표시를 분리하기
한 시점을 저장할 때는 UTC instant를 사용하고 표시할 때 locale과 time zone을 적용한다.
{
"recordedAt": "2026-02-03T01:20:00Z",
"displayTimeZone": "Asia/Seoul"
}
Instant 2026-02-03T01:20:00Z
Asia/Seoul → 2026-02-03 10:20
America/New_York → 2026-02-02 20:20
UTC offset +09:00만 저장하면 현재 instant 표시에는 충분할 수 있지만 지역의 미래 daylight saving rule을 표현하지 못한다. 예약과 반복 일정에는 IANA zone identifier가 필요하다.
{
"localTime": "09:00",
"timeZone": "America/New_York",
"recurrence": "DAILY"
}
정부 정책으로 time zone rule은 바뀔 수 있다. client·server의 tz database version 차이로 미래 시각 계산이 달라질 수 있음을 운영 시나리오에 포함한다.
Locale은 날짜 순서와 이름을 format하고 time zone은 그 instant의 local clock을 결정한다. 둘을 바꿔 쓰지 않는다.
서버 알림은 발송 시점의 설정으로 만들기
작업 생성 시 한국어 완성 문장을 queue에 저장하고 몇 시간 뒤 발송하면 그 사이 사용자가 영어로 바꿔도 한국어 알림이 간다.
{
"title": "새 기록이 도착했습니다"
}
가능하면 event에는 의미와 interpolation data를 저장하고 발송 worker가 최신 preference로 format한다.
{
"templateKey": "entry.created",
"arguments": {
"count": 3
},
"recipientId": "user-demo"
}
발송 시:
async function renderNotification(
job: NotificationJob,
): Promise<RenderedNotification> {
const preference = await preferences.forChannel(
job.recipientId,
"push",
);
return translations.render({
locale: preference.locale,
key: job.templateKey,
arguments: job.arguments,
});
}
감사 목적상 “event 발생 당시 언어”가 필요한 도메인은 snapshot을 함께 저장할 수 있다. 마케팅·transaction email·push마다 요구가 다르므로 정책을 명시한다.
번역 문자열을 이어 붙이지 않기
언어마다 어순과 복수형이 다르므로 조각을 조합하지 않는다.
// 좋지 않은 예
Text('$count' + localizations.items + localizations.saved);
ICU message 같은 전체 문장 단위 번역을 사용한다.
{
"savedEntryCount": "{count, plural, =0{저장된 기록이 없습니다} one{기록 1개를 저장했습니다} other{기록 {count}개를 저장했습니다}}",
"@savedEntryCount": {
"placeholders": {
"count": {
"type": "int"
}
}
}
}
예제 한국어에 one이 필수라는 의미가 아니라 localization 도구의 plural contract를 보여 주기 위한 예다. 각 locale의 CLDR plural rule을 사용한다.
숫자·날짜를 미리 문자열로 interpolation하지 않고 typed value를 formatter에 전달한다.
동시 변경과 Offline 상태 합치기
기기 A와 B가 locale을 바꾸거나 offline 기기가 오래된 설정을 나중에 보낼 수 있다.
sequenceDiagram
participant A as Device A
participant S as Server
participant B as Device B
A->>S: version 17 → ko-KR
S-->>A: version 18
B->>S: base version 16 → en-US
S-->>B: conflict, current version 18
B->>B: 사용자에게 최신 상태 반영언어 설정은 last-write-wins가 허용되는 경우가 많지만 server version으로 순서를 정한다. offline queue operation에 expected version을 포함한다.
Widget과 WebView는 server write 완료를 기다리지 않고 local effective locale을 즉시 적용할 수 있다. server sync 실패 상태는 별도로 표시하고 재시도한다.
local UI 적용: 즉시
App Group snapshot: 즉시
WebView event: 즉시
Backend preference: durable queue로 동기화
서버가 conflict를 반환하면 제품 정책에 따라 server 값을 적용하거나 사용자의 최신 의도를 새 version으로 다시 제출한다.
테스트할 Locale과 시간 경계
| 시나리오 | 검증 |
|---|---|
system + 지원 locale |
첫 지원 값 선택 |
system + 미지원 locale |
default fallback |
| explicit locale | OS 변경에도 유지 |
| script가 다른 Chinese | 올바른 asset |
| WebView reload | current snapshot 재요청 |
| Widget 독립 실행 | App Group locale 사용 |
| offline 변경 | local 즉시 적용·server 재시도 |
| 두 기기 충돌 | version conflict 처리 |
| DST 시작·종료 | local time 변환 |
| time zone 변경 | locale 유지, 시간만 변경 |
| 알림 예약 뒤 언어 변경 | 발송 시 최신 정책 |
| RTL locale | layout·icon direction |
| 긴 번역 | overflow·접근성 |
번역 key 누락을 build 또는 CI에서 검사하고 pseudo-localization으로 문자열 확장과 hard-coded text를 찾는다.
[!! Šàvëd 3 ëñtrïës — expanded !!]
날짜 golden test는 현재 OS locale data에 따라 흔들릴 수 있으므로 기대 formatter와 runtime version을 명확히 한다.
운영에서 번역 누락과 Drift 관측하기
locale_effective requested=zh-Hant-HK resolved=zh-Hant-TW
translation_missing locale=ja-JP key=entry.created runtime=widget
locale_sync_conflict client_version=16 server_version=18
notification_render_fallback requested=fr-FR fallback=en-US
사용자 ID와 notification 원문은 남기지 않는다. runtime별 missing key와 fallback 비율을 보면 Flutter에는 있는데 server template에는 없는 번역을 찾을 수 있다.
각 배포 artifact의 translation catalog version을 기록한다.
{
"flutterCatalog": "2026.02.1",
"webCatalog": "2026.02.1",
"widgetCatalog": "2026.01.4",
"serverCatalog": "2026.02.1"
}
catalog version이 다르다는 이유만으로 장애는 아니지만 새 message key가 구 Widget에 전달될 때 fallback이 있는지 판단할 수 있다.
구현 체크리스트
마무리
다국어 앱의 일관성은 번역 파일 개수보다 locale의 소유권에서 시작한다. Flutter, WebView, Backend, Widget이 각각 system locale과 요청 header를 보고 언어를 추정하면 같은 사용자의 화면과 알림이 달라진다.
사용자의 언어 preference를 system 또는 명시적인 BCP 47 tag로 저장하고, 지원 목록과 fallback으로 effective locale을 계산한다. Time zone은 locale에서 추측하지 않고 IANA identifier로 별도 관리한다. 이 version 있는 snapshot을 각 runtime이 소비하게 한다.
날짜는 UTC instant와 zone 의미를 보존하고 표시 시점에 지역화한다. 서버 알림은 완성 문장보다 template key와 의미 데이터를 queue에 넣어 발송 시 최신 preference로 만든다.
각 runtime이 같은 locale 문자열을 갖는 것만으로는 충분하지 않다. fallback, catalog version, 변경 순서, 시간대 의미까지 같은 계약으로 이해할 때 사용자는 하나의 앱을 사용하고 있다고 느낀다.
관련 노트
- Flutter와 iOS WidgetKit 사이에 데이터 공유하기
- WidgetKit Timeline을 갱신하는 방법
- WebView 브릿지를 버전 있는 프로토콜로 만들기
- 오프라인 큐에 멱등성이 필요한 이유
- FCM 알림 탭과 앱 라우팅 연결하기